<?php

Raise::load('core.RaiseHelper');
Raise::load('core.RaiseApplication');
Raise::load('core.RaiseTimeSpan');
Raise::load('core.RaiseConvert');
Raise::load('core.RaiseHashDigest');

/**
 * RaiseCsrfProtect class
 * set protection against CSRF attacks
 *
 * @author Sam-Mauris Yong <hellclanner at live dot com>
 * @license http://www.opensource.org/licenses/bsd-license New BSD License
 * @package Raise.Core.Security
 * @link http://en.wikipedia.org/wiki/Cross-site_request_forgery
 * @since 1.2
 */
class RaiseCsrfProtect extends RaiseHelper {

    /**
     * Session key to use for the protection
     */
    const SESSION_KEY = '_?:RaiseCsrfProtect';

    /**
     * The time to live for tokens
     * @var RaiseTimeSpan
     */
    private $timeToLive;

    /**
     * The RaiseApplication that RaiseCsrfProtect will protect
     * @var RaiseApplication
     */
    private $application;

    /**
     * Create a new RaiseCsrfProtect
     * @param RaiseApplication $app  The application to provide CSRF protection for
     */
    public function __construct($app){
        $this->application($app);
        $this->timeToLive = new RaiseTimeSpan(36000);
    }

    /**
     * Get or set the RaiseApplication that RaiseCsrfProtect will protect
     * @param RaiseApplication $app (optional) If set, the new value will be set.
     * @return RaiseApplication
     */
    public function application($app = null){
        if(func_num_args() == 1){
            if(!($app instanceof RaiseApplication)){
                throw new RaiseInvalidArgumentException('RaiseCsrfProtect::application() expecting $app to be an instance of RaiseApplication, ' . RaiseVariable::typeInfo($app) . ' given instead.');
            }
            $this->application = $app;
        }
        return $this->application;
    }

    /**
     * Get or set the amount of time that the tokens should live before expiring
     * @param RaiseTimeSpan $t (optional) The new time span
     * @return RaiseTimeSpan
     */
    public function timeToLive($t = null){
        if(func_num_args() == 1){
            $this->timeToLive = $t;
        }
        return $this->timeToLive;
    }

    /**
     * Generates a token to use for a new form
     * @return string
     */
    private function generateToken(){
        return RaiseConvert::arbitraryBase(RaiseHashDigest::sha256($this->application->session()->id() . time() . self::SESSION_KEY), 16, (mt_rand(1,6) < 4 ? 30 : 36));
    }

    /**
     * Queries the HTML hidden field name to use
     * @return string
     * @static
     */
    public function fieldName(){
        return str_rot13('_s34surf' . RaiseHashDigest::sha256($this->application()->session()->id()) . self::SESSION_KEY);
    }

    /**
     * Creates a form token
     * @param integer $expire Set the expiry of the token
     */
    private function createFormToken(){
        $token = $this->generateToken();
        $d = $this->application()->session()->get(self::SESSION_KEY);
        if(!($d instanceof RaiseCollection)){
            $d = new RaiseCollection();
        }
        $expire = $this->timeToLive->totalSeconds();
        $d->add($d->count(), array($token, ($expire <= 0 ? 0 : (time() + $expire))));
        $this->application()->session()->set(self::SESSION_KEY, $d);
        return $token;
    }

    /**
     * Garbage clean up handler
     */
    private function garbageCollect(){
        $v = $this->application()->session()->get(self::SESSION_KEY);
        if(!($v instanceof RaiseCollection)){
            $v = new RaiseCollection();
        }
        $now = RaiseDateTime::now()->toTimestamp();
        foreach($v as &$a){
            if($a[1] > 0 && $a[1] < $now){
                unset($a);
            }
        }
        $this->application()->session()->set(self::SESSION_KEY, $v);
    }

    /**
     * Raises an attack detected error
     */
    private function raiseError(){
        throw new RaiseInvalidRequestException('A CSRF attack has been detected.');
    }

    /**
     * Enables and initializes the protection
     * @return boolean
     */
    public function enable(){
        $field = $this->fieldName();
        $this->garbageCollect();
        if($this->application()->request()->post()->count() > 0){
            // there's some post content incoming
            if(!$this->application()->request()->post()->keyExists($field)){
                $this->raiseError();
            }else{
                $v = $this->application()->session()->get(self::SESSION_KEY);
                foreach($v as $a){
                    if($this->application()->request()->post($field) == $a[0]){
                        return true;
                        break;
                    }
                }
                $this->raiseError();
            }
        }
    }
    
    /**
     * Prepare a HTML output for CSRF protection. This method will automatically detect if the settings are enabled.
     * @param string $html 
     * @return string
     */
    public function prepare($html){
        if ($this->application()->settings()->get('phpRaise', 'enableCsrfProtection')) {
            $html = $this->ob_rewrite($html);
        }
        return $html;
    }

    /**
     * Rewrite the HTML output by finding all POST forms on the page
     * then inserting the hidden field.
     * @param string $html Input HTML code
     * @return string
     */
    private function ob_rewrite($html){

        $hidden = '<div style="display:none;"><input type="hidden" name="';
        $hidden .= $this->fieldName() . '" value="' . $this->createFormToken();
        $hidden .= '" /></div>';

        return preg_replace(
            '/(<form\W[^>]*\bmethod=(\'|"|)POST(\'|"|)\b[^>]*>)/i',
            '\\1' . $hidden,
            $html
        );
   }
}
